Skip to content

Fix Purchase.synchronizeReceipts re-submitting same receipt and firing callback multiple times#4990

Merged
shai-almog merged 2 commits into
masterfrom
fix/purchase-iap-multiple-submit-receipt
May 20, 2026
Merged

Fix Purchase.synchronizeReceipts re-submitting same receipt and firing callback multiple times#4990
shai-almog merged 2 commits into
masterfrom
fix/purchase-iap-multiple-submit-receipt

Conversation

@shai-almog
Copy link
Copy Markdown
Collaborator

@shai-almog shai-almog commented May 20, 2026

Summary

Forum report: a user implementing ReceiptStore (storage-style — submitReceipt posts to their server and calls callback.onSucess(true) inline) sees submitReceipt invoked many times after a single in-app purchase. Investigation surfaced several bugs and a cross-platform abstraction gap.

Commit 1 — Fix three bugs in synchronizeReceipts

Bug 1: receipts with a null transactionId are resubmitted forever. removePendingPurchase(String transactionId) only matched receipts with a non-null stored transactionId. If a receipt with null tx reached the queue, remove was a no-op, the recursion picked it back up, and submitReceipt fired forever. removePendingPurchase now takes the Receipt itself and falls back to matching on (sku, storeCode, purchaseDate, orderData) when transactionId is null on either side.

Bug 2: caller's SuccessCallback fires N times for N pending receipts. The recursive synchronizeReceipts(0, callback) re-registered the user's callback every iteration. Now passes null since the callback is already registered on the top-level call.

Bug 3: thrown exceptions permanently wedge syncInProgress. syncInProgress = false was set after removePendingPurchase. Any throw left it stuck true for the rest of the app's lifetime. Now reset at the top of onSucess before any work that can throw.

Commit 2 — Dedupe submitReceipt on transactionId across the install lifetime

The framework's implicit contract was "submitReceipt fires once per pending-queue entry," which leaks platform behavior: iOS StoreKit redelivers unfinished transactions across sessions, sandbox subscription renewals fire on a compressed schedule, etc. — each delivery hits postReceipt and produces another submitReceipt call with the same transactionId.

Now backed by a persistent List<String> of processed transactionIds (Storage key ProcessedPurchases.dat):

  1. addPendingPurchase drops a receipt whose transactionId is already in the processed set, or already sitting in the pending queue. Duplicate postReceipt calls never reach the queue.
  2. The success branch records the transactionId before removePendingPurchase, so a parallel re-enqueue racing the remove is also dropped.

Receipts with a null transactionId can't be tracked in the set; they fall back to the existing receiptsMatch-based in-pending dedup added in commit 1.

The user-facing contract is now: submitReceipt is invoked at most once per transactionId for the lifetime of the install, on every platform.

Test plan

  • Five new regression tests in PurchaseTest:
    • testSynchronizeReceiptsSyncDrainsMultiplePendingReceipts — each pending receipt is submitted exactly once.
    • testSynchronizeReceiptsCallbackFiresOnceWhenDrainingMultiplePendingReceipts — user callback fires once, not N times.
    • testSynchronizeReceiptsDoesNotInfinitelyResubmitReceiptWithNullTransactionId — uses a CountingReceiptStore that throws after a cap so a regression fails the test instead of hanging it.
    • testPostReceiptSkipsReceiptThatWasAlreadySuccessfullySubmitted — simulates iOS cross-session redelivery.
    • testPostReceiptSkipsDuplicateTransactionIdAlreadyPending — same-session double-fire before sync runs.
  • All 16 tests in PurchaseTest pass (mvn test -Dtest=PurchaseTest).
  • Verified each new test fails when the corresponding fix is reverted (e.g. submitReceipt invoked more than 5 times; pending receipt is being resubmitted in a loop; cross-session redelivery test fails expected: <1> but was: <2> without the dedup).

🤖 Generated with Claude Code

…g callback N times

Three closely-related bugs surfaced from a forum report of submitReceipt
being invoked repeatedly:

1. removePendingPurchase matched only on transactionId and silently
   no-op'd when the receipt's transactionId was null. The pending queue
   then still contained the receipt, the recursion at the end of
   synchronizeReceipts pulled it again, and the same receipt was
   re-submitted forever. removePendingPurchase now takes the Receipt
   itself and falls back to matching on (sku, storeCode, purchaseDate,
   orderData) when transactionId is null on either side.

2. The recursive synchronizeReceipts(0, callback) re-registered the
   caller's SuccessCallback on every iteration, so a queue of N pending
   receipts caused the user's callback to fire N times. The recursive
   call now passes null since the original callback is already in
   synchronizeReceiptsCallbacks.

3. syncInProgress was reset to false after removePendingPurchase, so a
   throw from removePendingPurchase (or any user-supplied submitReceipt
   implementation) permanently wedged the sync state. syncInProgress is
   now reset at the top of onSucess.

Added three regression tests in PurchaseTest:
- testSynchronizeReceiptsSyncDrainsMultiplePendingReceipts: each pending
  receipt is submitted exactly once.
- testSynchronizeReceiptsCallbackFiresOnceWhenDrainingMultiplePendingReceipts
- testSynchronizeReceiptsDoesNotInfinitelyResubmitReceiptWithNullTransactionId
  (uses a CountingReceiptStore that throws after a cap so a regression
  fails the test instead of hanging it).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented May 20, 2026

✅ Continuous Quality Report

Test & Coverage

Static Analysis

  • SpotBugs [Report archive]
    • ByteCodeTranslator: 0 findings (no issues)
    • android: 0 findings (no issues)
    • codenameone-maven-plugin: 0 findings (no issues)
    • core-unittests: 0 findings (no issues)
    • ios: 0 findings (no issues)
  • PMD: 0 findings (no issues) [Report archive]
  • Checkstyle: 0 findings (no issues) [Report archive]

Generated automatically by the PR CI workflow.

@shai-almog
Copy link
Copy Markdown
Collaborator Author

shai-almog commented May 20, 2026

Compared 110 screenshots: 110 matched.

Native Android coverage

  • 📊 Line coverage: 11.82% (6581/55655 lines covered) [HTML preview] (artifact android-coverage-report, jacocoAndroidReport/html/index.html)
    • Other counters: instruction 9.53% (33026/346569), branch 4.12% (1358/32925), complexity 5.19% (1637/31558), method 9.02% (1331/14749), class 15.07% (301/1998)
    • Lowest covered classes
      • kotlin.collections.kotlin.collections.ArraysKt___ArraysKt – 0.00% (0/6327 lines covered)
      • kotlin.collections.unsigned.kotlin.collections.unsigned.UArraysKt___UArraysKt – 0.00% (0/2384 lines covered)
      • org.jacoco.agent.rt.internal_b6258fc.asm.org.jacoco.agent.rt.internal_b6258fc.asm.ClassReader – 0.00% (0/1519 lines covered)
      • kotlin.collections.kotlin.collections.CollectionsKt___CollectionsKt – 0.00% (0/1148 lines covered)
      • org.jacoco.agent.rt.internal_b6258fc.asm.org.jacoco.agent.rt.internal_b6258fc.asm.MethodWriter – 0.00% (0/923 lines covered)
      • kotlin.sequences.kotlin.sequences.SequencesKt___SequencesKt – 0.00% (0/730 lines covered)
      • kotlin.text.kotlin.text.StringsKt___StringsKt – 0.00% (0/623 lines covered)
      • org.jacoco.agent.rt.internal_b6258fc.asm.org.jacoco.agent.rt.internal_b6258fc.asm.Frame – 0.00% (0/564 lines covered)
      • kotlin.collections.kotlin.collections.ArraysKt___ArraysJvmKt – 0.00% (0/495 lines covered)
      • kotlinx.coroutines.kotlinx.coroutines.JobSupport – 0.00% (0/423 lines covered)

✅ Native Android screenshot tests passed.

Native Android coverage

  • 📊 Line coverage: 11.82% (6581/55655 lines covered) [HTML preview] (artifact android-coverage-report, jacocoAndroidReport/html/index.html)
    • Other counters: instruction 9.53% (33026/346569), branch 4.12% (1358/32925), complexity 5.19% (1637/31558), method 9.02% (1331/14749), class 15.07% (301/1998)
    • Lowest covered classes
      • kotlin.collections.kotlin.collections.ArraysKt___ArraysKt – 0.00% (0/6327 lines covered)
      • kotlin.collections.unsigned.kotlin.collections.unsigned.UArraysKt___UArraysKt – 0.00% (0/2384 lines covered)
      • org.jacoco.agent.rt.internal_b6258fc.asm.org.jacoco.agent.rt.internal_b6258fc.asm.ClassReader – 0.00% (0/1519 lines covered)
      • kotlin.collections.kotlin.collections.CollectionsKt___CollectionsKt – 0.00% (0/1148 lines covered)
      • org.jacoco.agent.rt.internal_b6258fc.asm.org.jacoco.agent.rt.internal_b6258fc.asm.MethodWriter – 0.00% (0/923 lines covered)
      • kotlin.sequences.kotlin.sequences.SequencesKt___SequencesKt – 0.00% (0/730 lines covered)
      • kotlin.text.kotlin.text.StringsKt___StringsKt – 0.00% (0/623 lines covered)
      • org.jacoco.agent.rt.internal_b6258fc.asm.org.jacoco.agent.rt.internal_b6258fc.asm.Frame – 0.00% (0/564 lines covered)
      • kotlin.collections.kotlin.collections.ArraysKt___ArraysJvmKt – 0.00% (0/495 lines covered)
      • kotlinx.coroutines.kotlinx.coroutines.JobSupport – 0.00% (0/423 lines covered)

Benchmark Results

Detailed Performance Metrics

Metric Duration
Base64 payload size 8192 bytes
Base64 benchmark iterations 6000
Base64 native encode 976.000 ms
Base64 CN1 encode 108.000 ms
Base64 encode ratio (CN1/native) 0.111x (88.9% faster)
Base64 native decode 944.000 ms
Base64 CN1 decode 226.000 ms
Base64 decode ratio (CN1/native) 0.239x (76.1% faster)
Image encode benchmark status skipped (SIMD unsupported)

@shai-almog
Copy link
Copy Markdown
Collaborator Author

shai-almog commented May 20, 2026

Compared 110 screenshots: 110 matched.
✅ Native iOS Metal screenshot tests passed.

Benchmark Results

  • VM Translation Time: 0 seconds
  • Compilation Time: 266 seconds

Build and Run Timing

Metric Duration
Simulator Boot 77000 ms
Simulator Boot (Run) 1000 ms
App Install 12000 ms
App Launch 33000 ms
Test Execution 255000 ms

Detailed Performance Metrics

Metric Duration
Base64 payload size 8192 bytes
Base64 benchmark iterations 6000
Base64 native encode 633.000 ms
Base64 CN1 encode 1228.000 ms
Base64 encode ratio (CN1/native) 1.940x (94.0% slower)
Base64 native decode 270.000 ms
Base64 CN1 decode 949.000 ms
Base64 decode ratio (CN1/native) 3.515x (251.5% slower)
Base64 SIMD encode 449.000 ms
Base64 encode ratio (SIMD/native) 0.709x (29.1% faster)
Base64 encode ratio (SIMD/CN1) 0.366x (63.4% faster)
Base64 SIMD decode 392.000 ms
Base64 decode ratio (SIMD/native) 1.452x (45.2% slower)
Base64 decode ratio (SIMD/CN1) 0.413x (58.7% faster)
Image encode benchmark iterations 100
Image createMask (SIMD off) 56.000 ms
Image createMask (SIMD on) 9.000 ms
Image createMask ratio (SIMD on/off) 0.161x (83.9% faster)
Image applyMask (SIMD off) 124.000 ms
Image applyMask (SIMD on) 72.000 ms
Image applyMask ratio (SIMD on/off) 0.581x (41.9% faster)
Image modifyAlpha (SIMD off) 137.000 ms
Image modifyAlpha (SIMD on) 52.000 ms
Image modifyAlpha ratio (SIMD on/off) 0.380x (62.0% faster)
Image modifyAlpha removeColor (SIMD off) 139.000 ms
Image modifyAlpha removeColor (SIMD on) 60.000 ms
Image modifyAlpha removeColor ratio (SIMD on/off) 0.432x (56.8% faster)
Image PNG encode (SIMD off) 980.000 ms
Image PNG encode (SIMD on) 801.000 ms
Image PNG encode ratio (SIMD on/off) 0.817x (18.3% faster)
Image JPEG encode 515.000 ms

@shai-almog
Copy link
Copy Markdown
Collaborator Author

shai-almog commented May 20, 2026

Compared 110 screenshots: 110 matched.
✅ Native iOS screenshot tests passed.

Benchmark Results

  • VM Translation Time: 0 seconds
  • Compilation Time: 156 seconds

Build and Run Timing

Metric Duration
Simulator Boot 63000 ms
Simulator Boot (Run) 1000 ms
App Install 12000 ms
App Launch 14000 ms
Test Execution 328000 ms

Detailed Performance Metrics

Metric Duration
Base64 payload size 8192 bytes
Base64 benchmark iterations 6000
Base64 native encode 1217.000 ms
Base64 CN1 encode 1808.000 ms
Base64 encode ratio (CN1/native) 1.486x (48.6% slower)
Base64 native decode 389.000 ms
Base64 CN1 decode 1210.000 ms
Base64 decode ratio (CN1/native) 3.111x (211.1% slower)
Base64 SIMD encode 634.000 ms
Base64 encode ratio (SIMD/native) 0.521x (47.9% faster)
Base64 encode ratio (SIMD/CN1) 0.351x (64.9% faster)
Base64 SIMD decode 615.000 ms
Base64 decode ratio (SIMD/native) 1.581x (58.1% slower)
Base64 decode ratio (SIMD/CN1) 0.508x (49.2% faster)
Image encode benchmark iterations 100
Image createMask (SIMD off) 90.000 ms
Image createMask (SIMD on) 15.000 ms
Image createMask ratio (SIMD on/off) 0.167x (83.3% faster)
Image applyMask (SIMD off) 160.000 ms
Image applyMask (SIMD on) 98.000 ms
Image applyMask ratio (SIMD on/off) 0.613x (38.7% faster)
Image modifyAlpha (SIMD off) 158.000 ms
Image modifyAlpha (SIMD on) 73.000 ms
Image modifyAlpha ratio (SIMD on/off) 0.462x (53.8% faster)
Image modifyAlpha removeColor (SIMD off) 1860.000 ms
Image modifyAlpha removeColor (SIMD on) 94.000 ms
Image modifyAlpha removeColor ratio (SIMD on/off) 0.051x (94.9% faster)
Image PNG encode (SIMD off) 1582.000 ms
Image PNG encode (SIMD on) 1099.000 ms
Image PNG encode ratio (SIMD on/off) 0.695x (30.5% faster)
Image JPEG encode 548.000 ms

…r id

Until now the framework's contract was effectively "submitReceipt fires
once per pending-queue entry," which leaks platform-level behavior to
the user: iOS StoreKit can redeliver an unfinished transaction across
app sessions, and sandbox subscription renewals fire repeatedly — each
delivery hits postReceipt and produces another submitReceipt call with
the same transactionId. The user-facing contract was inconsistent
across platforms.

Add a persistent List<String> of processed transactionIds in CN1
Storage (key ProcessedPurchases.dat). Two checks close the gap:

1. addPendingPurchase drops a receipt whose transactionId is already
   in the processed set, or already sitting in the pending queue.
   So duplicate postReceipt calls — from iOS cross-session redelivery,
   in-session double-fire, or anything else — never reach the queue.

2. The success branch of synchronizeReceipts records the
   transactionId in the processed set before removing the receipt
   from pending. Doing it in that order means a parallel
   re-enqueue racing the remove is also dropped.

Receipts with a null transactionId can't be tracked in the set; they
fall back to the existing receiptsMatch-based in-pending dedup.

Two new tests:
- testPostReceiptSkipsReceiptThatWasAlreadySuccessfullySubmitted
  (cross-session redelivery — verified the test fails 1 vs 2 without
  the fix)
- testPostReceiptSkipsDuplicateTransactionIdAlreadyPending
  (same-session double-fire before sync runs)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@shai-almog shai-almog merged commit 750cde7 into master May 20, 2026
18 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant